Skip to content

Add aspire destroy command for tearing down deployed environments#16097

Merged
davidfowl merged 35 commits intomainfrom
davidfowl/aspire-destroy
Apr 13, 2026
Merged

Add aspire destroy command for tearing down deployed environments#16097
davidfowl merged 35 commits intomainfrom
davidfowl/aspire-destroy

Conversation

@davidfowl
Copy link
Copy Markdown
Contributor

Description

Add aspire destroy — a new top-level CLI command that tears down previously deployed Aspire environments. Completes the deployment lifecycle: aspire publish → aspire deploy → aspire destroy.

What it does

Each compute environment implements its own destroy step with contextual confirmation:

  • Azure (ACA/App Service): discovers resources in the resource group via ARM, shows them to the user, confirms, then deletes the resource group
  • Docker Compose: confirms, then runs docker compose down using persisted deployment state
  • Kubernetes (Helm): confirms, then runs helm uninstall using persisted release name and namespace

Key design decisions

  • Per-environment confirmation prompts with full context (RG name + resource count, Helm release + namespace, Compose environment name). --yes/-y skips prompts.
  • Deployment state persistence — Docker Compose and Helm now save minimal state during deploy (output path, project name, release name, namespace) so destroy targets exactly what was deployed, even if the AppHost model changed.
  • Layered pipeline steps — each environment has a destroy-{env} step (confirm + action) and a standalone action step (docker-compose-down, helm-uninstall) callable via aspire do without confirmation.
  • Non-interactive fail-fast — running without --yes in non-interactive mode throws immediately instead of silently proceeding.
  • State cleanup — deployment state file is cleared after successful destroy via IDeploymentStateManager.ClearAllStateAsync.

New abstractions

  • WellKnownPipelineSteps.Destroy / DestroyPrereq — pipeline aggregation steps
  • PipelineOptions.SkipConfirmation — forwarded from CLI --yes flag
  • IDeploymentStateManager.ClearAllStateAsync() — centralized state cleanup
  • IResourceGroupResource.DeleteAsync() / GetResourcesAsync() — ARM abstractions for destroy
  • IHelmRunner — testable abstraction for Helm CLI operations

Validation

  • Manual testing: Azure ACA deploy→destroy (interactive + --yes), Docker Compose deploy→destroy (interactive + --yes)
  • Unit tests: 5 CLI command tests, 3 Azure destroy pipeline tests (with state, no state, non-interactive), 2 Docker Compose destroy tests + 1 deploy→destroy roundtrip, 2 Helm destroy tests, pipeline wiring tests
  • E2E test updates: 17 deployment test files updated to use aspire destroy --yes via shared AspireDestroyAsync helper

Fixes #13013

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
      • If yes, did you have an API Review for it?
        • Yes
        • No
      • Did you add <remarks /> and <code /> elements on your triple slash comments?
        • Yes
        • No
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
    • No
  • Does the change require an update in our Aspire docs?

davidfowl and others added 21 commits April 12, 2026 09:02
Implements #13013 - adds a top-level 'aspire destroy' command that tears down
previously deployed Aspire environments. The command follows the same pipeline
architecture as 'aspire deploy' and 'aspire publish'.

Changes:
- Add WellKnownPipelineSteps.Destroy and DestroyPrereq aggregation steps
- Add DestroyCommand CLI command with --yes flag to skip confirmation
- Add destroy-prereq step with interactive confirmation prompt
- Wire Docker Compose's existing docker-compose-down step to destroy
- Wire Kubernetes Helm's existing helm-uninstall step to destroy
- Add Azure resource group deletion via ARM SDK for ACA/App Service
- Add IResourceGroupResource.DeleteAsync to provisioning abstractions
- Add PipelineOptions.Yes for forwarding --yes flag to AppHost
- Update pipeline step count test and accept diagnostics snapshots

Validated with pipeline tests (65), Docker Compose tests (85),
Kubernetes tests (88), and Azure deployer tests (28) all passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep the prereq generic — each environment step already surfaces
target-specific details (resource group, Helm release, compose project).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Query the resource group via ARM to list all resources before deleting,
so users can see exactly what will be destroyed. The discovery phase
logs each resource type and name, then reports the total count.

Pipeline output:
  Discovering resources in myapp-rg
    ContainerApps/containerApps: api
    KeyVault/vaults: kv-myapp
    ContainerRegistry/registries: acrmyapp
  Found 3 resource(s) in myapp-rg
  Deleting resource group myapp-rg (3 resource(s))

If enumeration fails (e.g. permissions), deletion still proceeds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- DestroyCommandTests: help, invalid project, --step destroy argument,
  --yes flag forwarding, --output-path inclusion (5 tests)
- K8s: HelmUninstallStep_RequiredByDestroy verifies helm-uninstall
  depends on destroy-prereq
- Register DestroyCommand in test DI (CliTestHelper.cs)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Each environment step now owns its own confirmation with full context:
- Azure: discovers resources in RG, then asks to confirm deletion
- Docker Compose: asks to confirm compose down
- Kubernetes: asks to confirm helm uninstall with release name + namespace

Pipeline layering: destroy → destroy-{env} (prompt) → action step (no prompt)
This means 'aspire do docker-compose-down' skips the prompt (explicit action),
while 'aspire destroy' chains through the confirmation layer.

The generic destroy-prereq is now a plain no-op placeholder step.
--yes skips all confirmation prompts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace manual cleanup commands with 'aspire destroy --yes':
- Azure (14 files): add aspire destroy step before exit, keep
  CleanupResourceGroupAsync as safety net in finally block
- Docker (2 tests): replace 'docker compose down' with aspire destroy
- Podman (1 test): replace 'podman compose down' with aspire destroy
- Kubernetes (1 test): replace 'helm uninstall' with aspire destroy,
  keep KinD cluster deletion separate

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fail fast when --yes is not set and interactivity is unavailable,
  instead of silently proceeding with destruction
- Consolidate destroy steps: each environment's destroy step does
  confirm + action in one step, keeping standalone action steps
  (docker-compose-down, helm-uninstall) clean for aspire do usage
- destroy-prereq is now a plain no-op placeholder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The destroy aggregation step now deletes the deployment state file
after all environment destroy steps succeed, acting as an implicit
cache clear. This ensures the next deploy starts fresh.

Removed per-section Azure state cleanup since the whole file is
now deleted at the end.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Move non-interactive/--yes check before ARM calls in Azure destroy
  so it fails fast without doing expensive Azure work
- Document that state file deletion is intentional (includes saved
  parameters — expected for full environment teardown)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tell the user to pass the same --output-path they used during deploy,
instead of just saying the file doesn't exist.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Include the actual error output (e.g. 'Cannot connect to Docker daemon')
instead of generic 'ensure runtime is installed' guidance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Save minimal deployment state during deploy so destroy can verify
what was actually deployed:

Docker Compose: saves OutputPath, ProjectName, ComposeFilePath
  to DockerCompose:{name} state section during compose-up

Helm: saves ReleaseName, Namespace
  to Helm:{name} state section during helm-deploy

Destroy steps now check for deployment state first and report
'Nothing to destroy' instead of failing with confusing errors
when no deployment exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1. Merge Azure destroy step into existing PipelineStepAnnotation
2. Flatten await using blocks to reduce nesting
3. Remove global::Azure prefix (using Azure; works fine)
4. Add IDeploymentStateManager.ClearAllStateAsync for centralized cleanup
5. Per-environment destroy steps now clean up their own state sections
6. Extract shared AspireDestroyAsync helper for E2E test cleanup,
   removing duplication across 17 test files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Full destroy is a full reset — clears parameters, Azure config,
and per-environment state. Per-section cleanup in environment steps
handles partial/scoped operations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Each environment now adds summary entries showing what was destroyed:
- Azure: resource group name + subscription
- Docker Compose: environment name
- Helm: release name + namespace

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Consolidate all state file mutations through IDeploymentStateManager
so in-memory state is also reset correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three new tests covering the destroy pipeline for Azure:
- WithAzureState: verifies RG discovery and deletion runs
- WithNoAzureState: verifies 'Nothing to destroy' message
- NonInteractiveWithoutYes: verifies fail-fast with --yes guidance

Added InMemoryDeploymentStateManager for stateful test scenarios.
Added deploymentStateManager parameter to ConfigureTestServices.
Updated all IDeploymentStateManager mocks with ClearAllStateAsync.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reliability fixes from code review:
- Docker destroy now uses saved ComposeFilePath/ProjectName from
  deployment state instead of recomputing from current model
- Helm destroy uses saved ReleaseName/Namespace for both the
  confirmation prompt and the actual uninstall call
- Docker destroy only clears state after successful compose down,
  preserves state when compose file is missing

Test improvements:
- Extract InMemoryDeploymentStateManager to shared test code
- Add FakeContainerRuntime.WasComposeDownCalled tracking
- Add 2 Docker Compose destroy pipeline tests:
  - WithState: verifies compose down is called via FakeContainerRuntime
  - WithNoState: verifies 'Nothing to destroy' without calling compose down

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract IHelmRunner interface from HelmDeploymentEngine to enable
testability of Helm operations without requiring a real helm binary.

- IHelmRunner: abstraction for running helm CLI commands
- DefaultHelmRunner: production implementation using ProcessUtil
- FakeHelmRunner: test double that tracks calls and returns exit code 0
- Refactor HelmDeployAsync and HelmUninstallAsync to use IHelmRunner
- Register DefaultHelmRunner in DI via AddKubernetesInfrastructureCore

New tests:
- DestroyHelm_WithState: verifies helm uninstall is called with
  saved release name and namespace from deployment state
- DestroyHelm_WithNoState: verifies 'Nothing to destroy' without
  calling helm

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Make TestResourceGroupResource observable with WasDeleteCalled and
  WasGetResourcesCalled tracking, threaded through ARM client/subscription
- Azure destroy test now asserts ARM DeleteAsync and GetResourcesAsync
  were actually called, not just that the step was created
- Add deploy→destroy roundtrip test for Docker Compose that verifies
  state persisted during deploy is correctly consumed by destroy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Clearer intent — 'Yes' was ambiguous, 'SkipConfirmation' describes
exactly what the option does. The CLI flag remains --yes/-y.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 12, 2026 22:23
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 12, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 16097

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 16097"

Verifies that when helm uninstall exits non-zero, deployment state
is preserved so the user can retry aspire destroy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Lowercase non-proper-noun words in prompt titles (JamesNK)
- Revert --force back to --yes pending naming discussion
- Fix zero-width character introduced by sed in test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found: 1 bug (double task completion in HelmUninstallAsync), 1 formatting issue.

davidfowl and others added 2 commits April 13, 2026 00:45
docker compose down validates the compose file before executing,
which fails when build contexts referenced in the file no longer
exist on disk. This happens in normal deploy→destroy flows when
containers were built from temporary contexts.

Fix: destroy now uses project-name-only mode (no -f flag) for
compose down. The project name from saved deployment state is
sufficient — compose looks up running containers by project label.

Also make ComposeFilePath optional in ComposeOperationContext so
callers can opt into project-name-only mode.

Test updated to verify ComposeFilePath is null in destroy context.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Change from WaitUntil.Started to WaitUntil.Completed so destroy
actually waits for the RG to be deleted before reporting success.
This avoids the confusing 'deletion initiated' message and prevents
the stale-state problem where immediate redeploy fails.

Also closes #16100 since we now wait for completion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
davidfowl and others added 3 commits April 13, 2026 01:07
- Remove redundant FailAsync before throw in HelmUninstallAsync to
  avoid completing the task twice (JamesNK)
- Fix 8-space indent in ProvisioningTestHelpers (JamesNK)
- Revert Azure delete to WaitUntil.Started (faster for E2E tests)
  with honest messaging: 'deletion in progress, monitor in Azure portal'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Show 'Deletion in progress. Monitor in the Azure portal.' in the
pipeline summary so the async nature of RG deletion is visible
even after the pipeline output scrolls away.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make the monitoring link unmissable by showing the full URL
rather than hiding it behind 'Azure portal' link text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl davidfowl force-pushed the davidfowl/aspire-destroy branch from e7f6a56 to 72fe4d7 Compare April 13, 2026 08:15
Comment on lines +320 to +321
PrimaryButtonText = "Destroy",
SecondaryButtonText = "Cancel"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these need to be localized?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess probably not - since all the other strings we present to the user here aren't.

context.CancellationToken).ConfigureAwait(false);

var resources = new List<(string Name, string ResourceType)>();
await using var _ = discoveryTask.ConfigureAwait(false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these supposed to go in a scope so they get disposed before the end of the method?

await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false);

var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId);
context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})"));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of logging the portal URL to something that no longer exists? So the user can verify it doens't exist?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this is fully async. You run destroy and it exits immediately but the operation is still in progess.


var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId);
context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})"));
context.Summary.Add("🔑 Subscription", subscriptionId);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead log a URL to the subscription?

Comment on lines +360 to +383
private static async Task ConfirmDestroyAsync(PipelineStepContext context, string message)
{
var options = context.Services.GetRequiredService<IOptions<PipelineOptions>>();

if (!options.Value.SkipConfirmation)
{
var interactionService = context.Services.GetRequiredService<IInteractionService>();

if (!interactionService.IsAvailable)
{
throw new InvalidOperationException(
"Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode.");
}

var result = await interactionService.PromptNotificationAsync(
"Destroy environment",
message,
new NotificationInteractionOptions
{
Intent = MessageIntent.Confirmation,
ShowSecondaryButton = true,
ShowDismiss = false,
PrimaryButtonText = "Destroy",
SecondaryButtonText = "Cancel"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) why is the message get passed in, but all the other strings aren't? Seems like we could just hard code the message here as well.

- Scope discoveryTask await using block so it disposes before confirmation prompt
- Inline Docker compose destroy confirmation message into ConfirmDestroyAsync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

@davidfowl davidfowl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed feedback from @eerhardt in 84ce8cf:

  • Scoped discoveryTask await using block so it disposes before the confirmation prompt
  • Inlined the Docker compose destroy confirmation message into ConfirmDestroyAsync (takes environmentName now instead of a message string)
  • Localization: agreed, not needed since other strings in this area aren't localized
  • Portal URL: keeping as-is since the RG URL still shows deletion status while the async operation is in progress

@github-actions
Copy link
Copy Markdown
Contributor

🎬 CLI E2E Test Recordings — 68 recordings uploaded (commit 84ce8cf)

View recordings
Test Recording
AddPackageInteractiveWhileAppHostRunningDetached ▶️ View Recording
AddPackageWhileAppHostRunningDetached ▶️ View Recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View Recording
AgentInitCommand_DefaultSelection_InstallsSkillOnly ▶️ View Recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View Recording
AllPublishMethodsBuildDockerImages ▶️ View Recording
AspireAddPackageVersionToDirectoryPackagesProps ▶️ View Recording
AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps ▶️ View Recording
Banner_DisplayedOnFirstRun ▶️ View Recording
Banner_DisplayedWithExplicitFlag ▶️ View Recording
Banner_NotDisplayedWithNoLogoFlag ▶️ View Recording
CertificatesClean_RemovesCertificates ▶️ View Recording
CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate ▶️ View Recording
CertificatesTrust_WithUntrustedCert_TrustsCertificate ▶️ View Recording
ConfigSetGet_CreatesNestedJsonFormat ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View Recording
CreateAndRunEmptyAppHostProject ▶️ View Recording
CreateAndRunJavaEmptyAppHostProject ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateAndRunTypeScriptEmptyAppHostProject ▶️ View Recording
CreateAndRunTypeScriptStarterProject ▶️ View Recording
CreateJavaAppHostWithViteApp ▶️ View Recording
CreateStartAndStopAspireProject ▶️ View Recording
CreateTypeScriptAppHostWithViteApp ▶️ View Recording
DashboardRunWithOtelTracesReturnsNoTraces ▶️ View Recording
DeployK8sBasicApiService ▶️ View Recording
DeployK8sWithGarnet ▶️ View Recording
DeployK8sWithMongoDB ▶️ View Recording
DeployK8sWithMySql ▶️ View Recording
DeployK8sWithPostgres ▶️ View Recording
DeployK8sWithRabbitMQ ▶️ View Recording
DeployK8sWithRedis ▶️ View Recording
DeployK8sWithSqlServer ▶️ View Recording
DeployK8sWithValkey ▶️ View Recording
DeployTypeScriptAppToKubernetes ▶️ View Recording
DescribeCommandResolvesReplicaNames ▶️ View Recording
DescribeCommandShowsRunningResources ▶️ View Recording
DetachFormatJsonProducesValidJson ▶️ View Recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
GlobalMigration_HandlesCommentsAndTrailingCommas ▶️ View Recording
GlobalMigration_HandlesMalformedLegacyJson ▶️ View Recording
GlobalMigration_PreservesAllValueTypes ▶️ View Recording
GlobalMigration_SkipsWhenNewConfigExists ▶️ View Recording
GlobalSettings_MigratedFromLegacyFormat ▶️ View Recording
InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot ▶️ View Recording
InvalidAppHostPathWithComments_IsHealedOnRun ▶️ View Recording
LegacySettingsMigration_AdjustsRelativeAppHostPath ▶️ View Recording
LogsCommandShowsResourceLogs ▶️ View Recording
PsCommandListsRunningAppHost ▶️ View Recording
PsFormatJsonOutputsOnlyJsonToStdout ▶️ View Recording
PublishWithDockerComposeServiceCallbackSucceeds ▶️ View Recording
RestoreGeneratesSdkFiles ▶️ View Recording
RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes ▶️ View Recording
RunFromParentDirectory_UsesExistingConfigNearAppHost ▶️ View Recording
SecretCrudOnDotNetAppHost ▶️ View Recording
SecretCrudOnTypeScriptAppHost ▶️ View Recording
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View Recording
StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets ▶️ View Recording
StopAllAppHostsFromAppHostDirectory ▶️ View Recording
StopAllAppHostsFromUnrelatedDirectory ▶️ View Recording
StopNonInteractiveMultipleAppHostsShowsError ▶️ View Recording
StopNonInteractiveSingleAppHost ▶️ View Recording
StopWithNoRunningAppHostExitsSuccessfully ▶️ View Recording
UnAwaitedChainsCompileWithAutoResolvePromises ▶️ View Recording

📹 Recordings uploaded automatically from CI run #24352396979

@davidfowl davidfowl merged commit 213aea1 into main Apr 13, 2026
279 checks passed
@joperezr joperezr added this to the 13.3 milestone Apr 14, 2026
radical pushed a commit that referenced this pull request Apr 14, 2026
…6097)

* Add aspire destroy command for tearing down deployed environments

Implements #13013 - adds a top-level 'aspire destroy' command that tears down
previously deployed Aspire environments. The command follows the same pipeline
architecture as 'aspire deploy' and 'aspire publish'.

Changes:
- Add WellKnownPipelineSteps.Destroy and DestroyPrereq aggregation steps
- Add DestroyCommand CLI command with --yes flag to skip confirmation
- Add destroy-prereq step with interactive confirmation prompt
- Wire Docker Compose's existing docker-compose-down step to destroy
- Wire Kubernetes Helm's existing helm-uninstall step to destroy
- Add Azure resource group deletion via ARM SDK for ACA/App Service
- Add IResourceGroupResource.DeleteAsync to provisioning abstractions
- Add PipelineOptions.Yes for forwarding --yes flag to AppHost
- Update pipeline step count test and accept diagnostics snapshots

Validated with pipeline tests (65), Docker Compose tests (85),
Kubernetes tests (88), and Azure deployer tests (28) all passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Simplify destroy-prereq confirmation message

Keep the prereq generic — each environment step already surfaces
target-specific details (resource group, Helm release, compose project).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Enumerate Azure resources before destroying resource group

Query the resource group via ARM to list all resources before deleting,
so users can see exactly what will be destroyed. The discovery phase
logs each resource type and name, then reports the total count.

Pipeline output:
  Discovering resources in myapp-rg
    ContainerApps/containerApps: api
    KeyVault/vaults: kv-myapp
    ContainerRegistry/registries: acrmyapp
  Found 3 resource(s) in myapp-rg
  Deleting resource group myapp-rg (3 resource(s))

If enumeration fails (e.g. permissions), deletion still proceeds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add DestroyCommand unit tests and pipeline wiring tests

- DestroyCommandTests: help, invalid project, --step destroy argument,
  --yes flag forwarding, --output-path inclusion (5 tests)
- K8s: HelmUninstallStep_RequiredByDestroy verifies helm-uninstall
  depends on destroy-prereq
- Register DestroyCommand in test DI (CliTestHelper.cs)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Move confirmation prompts to environment-specific destroy steps

Each environment step now owns its own confirmation with full context:
- Azure: discovers resources in RG, then asks to confirm deletion
- Docker Compose: asks to confirm compose down
- Kubernetes: asks to confirm helm uninstall with release name + namespace

Pipeline layering: destroy → destroy-{env} (prompt) → action step (no prompt)
This means 'aspire do docker-compose-down' skips the prompt (explicit action),
while 'aspire destroy' chains through the confirmation layer.

The generic destroy-prereq is now a plain no-op placeholder step.
--yes skips all confirmation prompts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update deployment E2E tests to use aspire destroy for cleanup

Replace manual cleanup commands with 'aspire destroy --yes':
- Azure (14 files): add aspire destroy step before exit, keep
  CleanupResourceGroupAsync as safety net in finally block
- Docker (2 tests): replace 'docker compose down' with aspire destroy
- Podman (1 test): replace 'podman compose down' with aspire destroy
- Kubernetes (1 test): replace 'helm uninstall' with aspire destroy,
  keep KinD cluster deletion separate

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix review findings: non-interactive guard and step wiring

- Fail fast when --yes is not set and interactivity is unavailable,
  instead of silently proceeding with destruction
- Consolidate destroy steps: each environment's destroy step does
  confirm + action in one step, keeping standalone action steps
  (docker-compose-down, helm-uninstall) clean for aspire do usage
- destroy-prereq is now a plain no-op placeholder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clear deployment state after successful destroy

The destroy aggregation step now deletes the deployment state file
after all environment destroy steps succeed, acting as an implicit
cache clear. This ensures the next deploy starts fresh.

Removed per-section Azure state cleanup since the whole file is
now deleted at the end.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: fail-fast non-interactive guard and state cleanup docs

- Move non-interactive/--yes check before ARM calls in Azure destroy
  so it fails fast without doing expensive Azure work
- Document that state file deletion is intentional (includes saved
  parameters — expected for full environment teardown)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Improve error message when compose file not found during destroy

Tell the user to pass the same --output-path they used during deploy,
instead of just saying the file doesn't exist.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Surface stderr in compose down error messages

Include the actual error output (e.g. 'Cannot connect to Docker daemon')
instead of generic 'ensure runtime is installed' guidance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Persist deployment state for Docker Compose and Helm

Save minimal deployment state during deploy so destroy can verify
what was actually deployed:

Docker Compose: saves OutputPath, ProjectName, ComposeFilePath
  to DockerCompose:{name} state section during compose-up

Helm: saves ReleaseName, Namespace
  to Helm:{name} state section during helm-deploy

Destroy steps now check for deployment state first and report
'Nothing to destroy' instead of failing with confusing errors
when no deployment exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address manual CR feedback

1. Merge Azure destroy step into existing PipelineStepAnnotation
2. Flatten await using blocks to reduce nesting
3. Remove global::Azure prefix (using Azure; works fine)
4. Add IDeploymentStateManager.ClearAllStateAsync for centralized cleanup
5. Per-environment destroy steps now clean up their own state sections
6. Extract shared AspireDestroyAsync helper for E2E test cleanup,
   removing duplication across 17 test files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clear all deployment state on full destroy via ClearAllStateAsync

Full destroy is a full reset — clears parameters, Azure config,
and per-environment state. Per-section cleanup in environment steps
handles partial/scoped operations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add deployment summary to destroy output

Each environment now adds summary entries showing what was destroyed:
- Azure: resource group name + subscription
- Docker Compose: environment name
- Helm: release name + namespace

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use ClearAllStateAsync for --clear-cache instead of raw File.Delete

Consolidate all state file mutations through IDeploymentStateManager
so in-memory state is also reset correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Azure destroy unit tests with mockable state manager

Three new tests covering the destroy pipeline for Azure:
- WithAzureState: verifies RG discovery and deletion runs
- WithNoAzureState: verifies 'Nothing to destroy' message
- NonInteractiveWithoutYes: verifies fail-fast with --yes guidance

Added InMemoryDeploymentStateManager for stateful test scenarios.
Added deploymentStateManager parameter to ConfigureTestServices.
Updated all IDeploymentStateManager mocks with ClearAllStateAsync.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use persisted state for destroy operations and add Compose destroy tests

Reliability fixes from code review:
- Docker destroy now uses saved ComposeFilePath/ProjectName from
  deployment state instead of recomputing from current model
- Helm destroy uses saved ReleaseName/Namespace for both the
  confirmation prompt and the actual uninstall call
- Docker destroy only clears state after successful compose down,
  preserves state when compose file is missing

Test improvements:
- Extract InMemoryDeploymentStateManager to shared test code
- Add FakeContainerRuntime.WasComposeDownCalled tracking
- Add 2 Docker Compose destroy pipeline tests:
  - WithState: verifies compose down is called via FakeContainerRuntime
  - WithNoState: verifies 'Nothing to destroy' without calling compose down

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Introduce IHelmRunner abstraction and add Helm destroy tests

Extract IHelmRunner interface from HelmDeploymentEngine to enable
testability of Helm operations without requiring a real helm binary.

- IHelmRunner: abstraction for running helm CLI commands
- DefaultHelmRunner: production implementation using ProcessUtil
- FakeHelmRunner: test double that tracks calls and returns exit code 0
- Refactor HelmDeployAsync and HelmUninstallAsync to use IHelmRunner
- Register DefaultHelmRunner in DI via AddKubernetesInfrastructureCore

New tests:
- DestroyHelm_WithState: verifies helm uninstall is called with
  saved release name and namespace from deployment state
- DestroyHelm_WithNoState: verifies 'Nothing to destroy' without
  calling helm

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Improve test quality: observable ARM mocks and deploy→destroy roundtrip

- Make TestResourceGroupResource observable with WasDeleteCalled and
  WasGetResourcesCalled tracking, threaded through ARM client/subscription
- Azure destroy test now asserts ARM DeleteAsync and GetResourcesAsync
  were actually called, not just that the step was created
- Add deploy→destroy roundtrip test for Docker Compose that verifies
  state persisted during deploy is correctly consumed by destroy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Rename PipelineOptions.Yes to SkipConfirmation

Clearer intent — 'Yes' was ambiguous, 'SkipConfirmation' describes
exactly what the option does. The CLI flag remains --yes/-y.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add API compat suppressions for new interface members

Suppress CP0006 for ClearAllStateAsync added to IDeploymentStateManager
and Compose methods added to IContainerRuntime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix unused using in KubernetesEnvironmentExtensions

Removes redundant Microsoft.Extensions.DependencyInjection using
(only Extensions variant needed for TryAddSingleton).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Azure portal link to destroy summary

Link to the resource group in the portal so users can monitor
the async deletion operation or diagnose failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Extract AzurePortalUrls helper for shared portal URL generation

Consolidate portal URL construction into a single shared class:
- GetResourceGroupUrl: used by deploy summary and destroy summary
- GetDeploymentUrl(string, string, string): used by BicepProvisioner
- GetDeploymentUrl(ResourceIdentifier): used by BicepProvisioner

Removes duplicate URL construction logic from AzureEnvironmentResource
and BicepProvisioner.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback

- Improve --yes option description to be clearer (JamesNK)
- Change confirmation button text from 'Yes, destroy' to 'Destroy' (JamesNK)
- Fix Helm: throw on non-zero exit so state isn't cleared on failure (JamesNK)
- Fix ClearAllStateAsync: acquire _stateLock before mutating in-memory
  state to maintain locking discipline (JamesNK)
- Fix missing ReportingStep.CompleteAsync when compose file no longer
  exists during destroy (JamesNK)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use sentence case for destroy status messages

Change 'DESTROY COMPLETED'/'DESTROY FAILED' to 'Destroy completed'/
'Destroy failed' per review feedback (JamesNK).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add regression test for Helm uninstall failure preserving state

Verifies that when helm uninstall exits non-zero, deployment state
is preserved so the user can retry aspire destroy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address remaining review feedback

- Lowercase non-proper-noun words in prompt titles (JamesNK)
- Revert --force back to --yes pending naming discussion
- Fix zero-width character introduced by sed in test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix compose destroy failing on stale build contexts

docker compose down validates the compose file before executing,
which fails when build contexts referenced in the file no longer
exist on disk. This happens in normal deploy→destroy flows when
containers were built from temporary contexts.

Fix: destroy now uses project-name-only mode (no -f flag) for
compose down. The project name from saved deployment state is
sufficient — compose looks up running containers by project label.

Also make ComposeFilePath optional in ComposeOperationContext so
callers can opt into project-name-only mode.

Test updated to verify ComposeFilePath is null in destroy context.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Wait for Azure resource group deletion to complete

Change from WaitUntil.Started to WaitUntil.Completed so destroy
actually waits for the RG to be deleted before reporting success.
This avoids the confusing 'deletion initiated' message and prevents
the stale-state problem where immediate redeploy fails.

Also closes #16100 since we now wait for completion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Helm double task completion and improve Azure delete messaging

- Remove redundant FailAsync before throw in HelmUninstallAsync to
  avoid completing the task twice (JamesNK)
- Fix 8-space indent in ProvisioningTestHelpers (JamesNK)
- Revert Azure delete to WaitUntil.Started (faster for E2E tests)
  with honest messaging: 'deletion in progress, monitor in Azure portal'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add deletion status to destroy summary

Show 'Deletion in progress. Monitor in the Azure portal.' in the
pipeline summary so the async nature of RG deletion is visible
even after the pipeline output scrolls away.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Show portal URL inline in destroy status summary

Make the monitoring link unmissable by showing the full URL
rather than hiding it behind 'Azure portal' link text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback from eerhardt

- Scope discoveryTask await using block so it disposes before confirmation prompt
- Inline Docker compose destroy confirmation message into ConfirmDestroyAsync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add aspire do cleanup command for Azure (and unify cleanup semantics across environments)

7 participants